Markdown渲染:从解析到高亮到公式
前两节实现了core的状态管理和vue的UI组件。这节实现
@ai-chat/markdown——AI聊天中内容渲染的核心:Markdown解析、代码高亮和数学公式。这部分是组件库中技术难度最高的包。
AI聊天的Markdown渲染为什么特殊?
普通Markdown渲染只需要 markdown-it 就够了,但AI聊天场景有三个特殊需求:
1. 流式拼接的容错解析 AI流式输出是逐字符到达的,可能在一个代码块的中间就触发渲染。如果解析器不能容错处理不完整的Markdown,就会频繁闪烁或报错。
2. 异步代码高亮 Shiki 的语法是按语言动态加载的(grammar 异步 import,引擎可选 WASM 或 JS 正则实现)。在流式场景中,每收到一个字符都触发高亮会很慢。需要延迟高亮、缓存高亮器实例,或用流式 tokenizer 增量高亮。
3. 公式与代码的嵌套 AI回复中经常出现代码块内包含公式、公式内包含代码的情况。解析器需要正确处理这些嵌套,不能互相干扰。
先调研再实施:local-search + superpowers 工作流
Markdown 渲染是组件库里技术选点最多的模块(解析内核、代码高亮、公式、图表、XSS 防护、流式容错),一口气让AI直接写码很容易得到一个"能跑但每个选型都说不出为什么"的实现。这节用一套固定的调研流程代替拍脑袋:
第一步:local-search 出调研报告。用第5章自建的搜索 skill 做市场调研,报告输出到 docs 目录,约束文件命名规则(序号开头 + 中文名):
使用 local-search 调研 Markdown 渲染方案(解析内核、代码高亮、数学公式、图表、XSS 防护),
调研报告输出到 docs/ 目录,格式为 01、02 序号开头、文件名为中文的 MD 文件。
text
local-search 相比默认联网搜索的优势:搜索面更广(多引擎聚合)、会把结果汇总成分析性文档而不是链接列表。这次的调研结论:推荐 markdown-it——插件生态最全,代码高亮、数学公式、图表都有现成插件,XSS 防护方案清晰,自研封装一层即可。
第二步:superpowers 出需求 spec 和实施计划。在 docs 下建 superpowers/specs 与 superpowers/plans 两个目录,用 brainstorm 生成需求文档,用 writing-plans 生成实施计划,同样以序号+中文名+八位日期命名。AI 在计划里自动做技术决策(比如"选 DOMPurify——最安全但稍重"、"用 provide/inject 注入配置"),这些策略判断可以不用自己筛,但要过目一遍。
第三步:把工作流写进 CLAUDE.md,以后复杂功能点自动走这套流程:
以后对于复杂的功能点,按照以下工作流程:
1. 使用 local-search 进行市场与技术方案的调研
2. 使用 superpowers brainstorm 生成详细需求,输出到 docs/superpowers/specs
3. 按照 spec 用 writing-plans 产生实施计划,输出到 docs/superpowers/plans
text
这种"调研 → 需求 → 计划 → 实施"的固化比把流程封装成 skill 更轻——CLAUDE.md 是每次会话必读的,不用记任何触发词。
需求层面的两个关键决策:渲染用两段式(流式过程中快速渲染避免卡顿,流式结束后完整渲染);@ai-chat/vue 对 @ai-chat/markdown 保持 optional——不装 markdown 包也能用纯文本聊天。
MarkdownRenderer 的实现策略
当前项目用自定义的 simpleMarkdown 函数做MVP,而不是引入 markdown-it 或 unified。这是有意为之的——先用正则实现核心功能,跑通流程,再用专业库替换。
MVP 正则解析
function simpleMarkdown(text: string): string {
let html = text
html = html.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>')
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>')
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>')
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>')
html = html.replace(/\n/g, '<br>')
return html
}
typescript
覆盖了6种语法:代码块、行内代码、粗体、斜体、标题、换行。足够渲染大部分AI回复。
第一个 replace 是XSS防护——先转义HTML特殊字符,再用正则插入安全的HTML标签。顺序不能反——如果先插入HTML标签再转义,标签本身也会被转义。
v-html 与安全性
MarkdownRenderer 用 v-html 渲染HTML。Vue 3默认不推荐 v-html,因为它有XSS风险。但在AI聊天场景中,content来自后端API而非用户输入(用户输入只通过InputArea的 v-model 处理,不会被渲染为HTML),所以风险可控。
如果要进一步加固,可以引入 DOMPurify 做HTML消毒(当前 3.4.16,cure53 维护的 XSS 消毒事实标准,支持 HTML/MathML/SVG)——调研报告里"最安全但稍重"的选型,最终由 markdown 包统一落地下沉:
import DOMPurify from 'dompurify'
const rendered = computed(() => DOMPurify.sanitize(simpleMarkdown(props.content)))
typescript
升级路径:markdown-it(调研后的正式方案)
当正则不够用时,切换到 markdown-it(当前 15.0.x,CommonMark 规范 + 插件扩展,npm 6500+ 项目使用):
import MarkdownIt from 'markdown-it'
import taskLists from 'markdown-it-task-lists'
import texmath from 'markdown-it-texmath'
import katex from 'katex'
import { guardMathBlocks } from './formula-guard'
// markdown-it 15 默认导出是 callable 包装(值),实例类型需用 InstanceType 推导
let instance: InstanceType<typeof MarkdownIt> | null = null
export function useMarkdownIt(): InstanceType<typeof MarkdownIt> {
if (instance) return instance
instance = new MarkdownIt({
html: false, // 禁止原始HTML,XSS 第一道防线
linkify: true, // 自动识别链接
breaks: false,
typographer: true, // 智能引号
})
.use(taskLists, { enabled: true, label: true })
// texmath 的 dollars 预设同时注册块级 math_block($$…$$)与行内 math_inline($…$)规则
// 块级优先级确保 $$ 不被行内 $ 规则抢先吞掉;KaTeX 引擎显式注入
.use(texmath, { engine: katex, delimiters: 'dollars' })
// 渲染前公式容错预处理:多行 $$ 重聚合 / 未闭合补齐 / 货币符号排除 / Setext 标题保护
const rawRender = instance.render.bind(instance)
instance.render = (src: string, env?: any) => rawRender(guardMathBlocks(src), env)
return instance
}
typescript
项目实际落地的插件组合:
- markdown-it-task-lists:
- [ ]任务列表,AI 输出待办时常见 - markdown-it-texmath + katex:数学公式(详见下文 LatexBlock 一节),texmath 负责
$$/$分词,KaTeX 负责渲染,分工清晰 - fence 规则覆写:mermaid 代码块输出占位 div(
data-mermaid属性 encodeURIComponent 防注入),由 MarkdownRenderer 异步替换成图表——mermaid 体积大,放 optionalDependencies
切换只需要改一个函数,组件模板和样式不需要动。正则 MVP 和 markdown-it 的分界线:什么时候正则不够用?流式容错要处理"代码块切到一半"的中间态、公式要处理跨行 $$、列表要处理嵌套——这三件事正则都写不健壮,就是切换时机。
CodeBlock:代码高亮的分层策略
当前实现:纯文本显示
<script setup lang="ts">
defineProps<{
code: string
language?: string
}>()
</script>
<template>
<div class="ai-chat-code-block">
<div v-if="language" class="ai-chat-code-block__header">
{{ language }}
</div>
<pre class="ai-chat-code-block__pre"><code>{{ code }}</code></pre>
</div>
</template>
vue
MVP阶段不做语法高亮,只显示语言标签和等宽字体代码。这样做的理由:
- Shiki异步加载grammar会增加包体积和首屏时间
- 流式场景中频繁触发高亮有性能问题
- 先跑通流程,确认布局和样式正确,再加高亮
升级方案:Shiki
Shiki 是 VS Code 同款的代码高亮引擎(VS Code、VitePress、Nuxt 都在用),当前 4.x 版本。最简用法一行:
import { codeToHtml } from 'shiki'
async function highlightCode(code: string, lang: string) {
const html = await codeToHtml(code, {
lang,
theme: 'github-dark',
})
return html
}
typescript
直接 import { codeToHtml } from 'shiki' 是全量 bundle:所有主题和语言打包为异步 chunk,不用到就不会加载,但你要精确控制体积时不够。4.x 提供细粒度入口 shiki/core,项目实际落地的方案(对应 useHighlighter composable):
import { createHighlighterCore } from 'shiki/core'
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
// 模块级懒加载单例:整个应用只初始化一次
const highlighter = createHighlighterCore({
// 纯 JS 正则引擎替代 Oniguruma WASM——浏览器零额外加载步骤
engine: createJavaScriptRegexEngine(),
themes: [import('@shikijs/themes/github-light'), import('@shikijs/themes/github-dark')],
langs: [import('@shikijs/langs/javascript'), import('@shikijs/langs/typescript')],
})
typescript
四个工程决策:
createJavaScriptRegexEngine替代默认的 Oniguruma WASM 引擎:TextMate grammar 用 JS 正则执行,浏览器端免去 WASM 加载;代价是少数依赖 Oniguruma 特性的 grammar 兼容度略降,常规语言无感- 语言按需动态 import(
@shikijs/langs/*):白名单收敛到支持的语言集合(js/ts/jsx/tsx/vue/json/bash/html/css/scss/python/go/rust/markdown/yaml/sql 约 16 种),配语言别名归一化表(js→javascript、ts→typescript) - 双主题一次加载(github-light / github-dark):明暗切换时只换 CSS class,不需要重新高亮
- 模块级单例:高亮器初始化有成本(加载 grammar + 建 Highlighter),懒加载 + 缓存 Promise,全应用共享
流式代码块的专用武器:@shikijs/stream(4.x 起独立包)。它的思路是把"逐字符到达的代码块"按行喂给流式 tokenizer,只对新增行做增量高亮,未闭合的代码块也能稳定输出已确定部分——比"延迟高亮 + debounce"的传统策略(流式期间纯文本、结束后一次性高亮,或每 300ms 重刷一次,或 Web Worker 里算)体验更好,光标后面的代码始终是高亮的。传统三策略仍然适用于不支持流式 tokenizer 的场景。
集成 markdown-it + Shiki
import MarkdownIt from 'markdown-it'
import { codeToHtml } from 'shiki'
const md = new MarkdownIt({ html: false })
md.options.highlight = async (code, lang) => {
if (!lang) return `<pre><code>${escapeHtml(code)}</code></pre>`
return await codeToHtml(code, { lang, theme: 'one-dark-pro' })
}
typescript
注意 highlight 函数是异步的,markdown-it 默认不支持异步插件。需要用 markdown-it-async 包装或者在渲染完成后异步替换代码块。
LatexBlock:数学公式渲染
当前实现:纯文本
<template>
<span class="ai-chat-latex" :class="{ 'ai-chat-latex--display': display }">
{{ formula }}
</span>
</template>
vue
只用斜体衬线字体显示公式文本。这在不需要精确公式渲染的场景下够用。
升级方案:KaTeX
KaTeX 是 Khan Academy 开发的高性能数学公式渲染器(当前 0.19.0,项目锁 0.18.x 稳定线),同步渲染、输出 HTML + CSS,官方基准中显著快于 MathJax:
import katex from 'katex'
function renderLatex(formula: string, displayMode: boolean): string {
return katex.renderToString(formula, {
displayMode,
throwOnError: false, // 不抛错,显示错误文本
strict: false,
})
}
typescript
KaTeX 的特点:
- 同步 API(
renderToString),渲染不阻塞在异步回调里 - 支持大部分 LaTeX 数学语法
- 输出 HTML + CSS,自带专用字体(KaTeX_Main 等 woff2),CSS 必须一并引入公式才能正常显示
- SSR 友好
字体的工程坑:如果 CSS 在包入口处全局引入,@font-face 会污染宿主页面。项目的做法是 CSS 不做模块级隐式注入,通过子路径导出 @toimc/markdown/katex.css,需要公式样式的宿主自己按需 import。
在 Markdown 中识别公式
需要在 Markdown 解析阶段识别行内公式($...$)和块级公式($$...$$)。手写正则的话:
// 块级公式:$$E = mc^2$$
html = html.replace(/\$\$([\s\S]+?)\$\$/g, (_, formula) => {
return katex.renderToString(formula, { displayMode: true })
})
// 行内公式:$x^2 + y^2 = z^2$
html = html.replace(/\$([^\$\n]+?)\$/g, (_, formula) => {
return katex.renderToString(formula, { displayMode: false })
})
typescript
注意顺序:先处理块级公式 $$,再处理行内公式 $。否则块级公式会被行内正则错误匹配。项目实际没有手写这段正则,而是用 markdown-it-texmath 的 dollars 预设——它把块级 math_block($$…$$)和行内 math_inline($…$)注册成两条独立规则,块级优先级保证 $$ 不被行内规则抢先吞掉,原理与手写正则的顺序约束相同,但由插件保证:
.use(texmath, { engine: katex, delimiters: 'dollars' })
typescript
流式场景的公式容错(formula-guard 工具):流式输出时 $$ 可能只到了一半、公式体可能跨多行、文本里的美元金额($100)可能被误判。渲染前做一层预处理——多行 $$ 重聚合、未闭合公式补齐、货币符号排除、Setext 标题保护——管线串联顺序:extractCompleteMarkdown(流式完整性提取)→ guardMathBlocks(公式容错)→ markdown-it。
包的依赖关系与体积控制
@ai-chat/markdown 是整个组件库中最"重"的包,体积控制有四个手段:
1. Shiki 走细粒度 bundle:shiki/core + 纯 JS 正则引擎(省掉 onig.wasm 的加载),grammar 按语言动态 import 且白名单收敛(约 16 种语言),单语言 grammar 通常几十 KB 量级,用到才加载。
2. KaTeX CSS 按需注入:CSS 通过子路径导出(@toimc/markdown/katex.css),宿主需要公式渲染时自己引入,不在包入口全局注入 @font-face。
3. mermaid 放 optionalDependencies:图表渲染是低频需求,mermaid 体积大,按需安装;fence 规则里 mermaid 块先输出占位 div,装了才异步渲染。
4. 保持 markdown 包可选
用户如果只需要纯文本聊天,可以不用 @ai-chat/markdown,只安装 @ai-chat/core + @ai-chat/vue——这也是 vue 包依赖声明里 markdown 为 optional 的原因。
验收与样式调优
实施类任务(这次执行约 2 小时)的验收不在代码在页面。按实施计划实施完成后刷新 playground 检查,一次性完成的效果通常不够好,这轮暴露的真实问题:浅色模式下代码高亮颜色不对、段落换行间距过大、用户消息没靠右、代码块上下区域间隔过大且圆角割裂、明暗模式切换后代码区背景色不变。
调优手段还是那几件:
- 补全测试数据:"mock 数据把上面的几类 Markdown 中分类的数据都展示出来"——标题/列表/代码/公式/表格各来一份,问题自己现形
- 选中 DOM 精确反馈:Chrome DevTools 选中问题元素 copy 给AI,配自然语言描述("上面的代码分类与下面的代码区域之间有很大的间隔,这两个部分显得很孤立")
- 设计 skill 统一风格:"让 ui-ux-pro-max 来优化颜色与整体的风格统一,不要让上半部分显示的背景色在明亮模式下面是深色,尽可能适配暗黑与明亮两种模式"——视觉一致性交给专业 skill,比手调色值高效
调优后的结果:代码高亮正常、公式渲染正常、mermaid 图表能出,表格和列表样式仍有小问题,流式响应时的格式容错还需要进一步测试——迭代继续,这就是组件开发的日常。
还有一个值得记的会话细节:AI 执行中途如果和预期不一致(比如调研时输出了多份文档而不是约定的一份),按 Esc 立即打断,纠正后继续;在产物不符合约定时打断越早,浪费越少。
本章小结
- AI聊天的Markdown渲染有三个特殊需求:流式容错、异步高亮、嵌套处理
- 复杂模块先走"local-search 调研 → superpowers spec → writing-plans 计划"再实施,工作流写进 CLAUDE.md 固化
- MVP先用正则实现6种基础语法,跑通流程后升级到 markdown-it(15.x)+ texmath + task-lists
- 代码高亮:Shiki 4.x 细粒度 bundle(shiki/core + JS 引擎 + 按语言动态加载 + 双主题一次加载),流式代码块用 @shikijs/stream 增量高亮
- 数学公式用 KaTeX,块级
$$优先于行内$的规则由 texmath 保证,流式容错靠 formula-guard 预处理 - XSS 双防线:markdown-it
html: false+ DOMPurify 消毒 - markdown包是可选的,体积控制靠按需加载(grammar/katex.css/mermaid optional)
第10章到此完成。下一章我们进入样式与主题系统的开发:CSS Variables架构、暗色模式、响应式设计。
↑